{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "requirements: UNI & CONCH installed  \n",
    "https://github.com/mahmoodlab/UNI/tree/main   \n",
    "https://github.com/mahmoodlab/CONCH   \n",
    "   \n",
    "Search for \"TODO\" and fill in your HuggingFace login token."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "metadata": {},
   "outputs": [],
   "source": [
    "import os\n",
    "from os import listdir\n",
    "from os.path import isfile, join\n",
    "import math\n",
    "import time\n",
    "from tqdm import tqdm\n",
    "\n",
    "import pandas as pd\n",
    "import numpy as np\n",
    "import matplotlib\n",
    "import matplotlib.pyplot as plt\n",
    "from PIL import Image\n",
    "Image.MAX_IMAGE_PIXELS = None\n",
    "\n",
    "import scanpy as sc\n",
    "import anndata\n",
    "\n",
    "import timm\n",
    "from timm.data import resolve_data_config\n",
    "from timm.data.transforms_factory import create_transform\n",
    "\n",
    "from huggingface_hub import login\n",
    "\n",
    "import torchvision\n",
    "from torchvision import datasets, models, transforms\n",
    "from torchvision.io import read_image\n",
    "from torchvision.transforms import ToTensor\n",
    "\n",
    "import torch\n",
    "import torch.nn as nn\n",
    "import torch.nn.functional as F\n",
    "import torch.optim as optim\n",
    "from torch.optim import lr_scheduler\n",
    "from torch.utils.data import Dataset, DataLoader\n",
    "import torch.backends.cudnn as cudnn\n",
    "\n",
    "from tempfile import TemporaryDirectory"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "metadata": {},
   "outputs": [],
   "source": [
    "def get_img_patch_embd(img, \n",
    "                       adata,\n",
    "                       samplename,\n",
    "                       device,\n",
    "                       save_path=None):\n",
    "    # CONCH model\n",
    "    from conch.open_clip_custom import create_model_from_pretrained\n",
    "    pretrained_CONCH, preprocess_CONCH = create_model_from_pretrained('conch_ViT-B-16', \"hf_hub:MahmoodLab/conch\", device=device,\n",
    "                                        hf_auth_token=\"\") # TODO: need to replace \"\" by HuggingFace Login Token\n",
    "\n",
    "    # UNI model\n",
    "    from uni import get_encoder\n",
    "    login(token=\"\") # TODO: need to replace \"\" by HuggingFace Login Token\n",
    "    model_UNI, transform_UNI = get_encoder(enc_name='uni', device=device)\n",
    "\n",
    "\n",
    "    def get_img_embd_conch(patch, model=pretrained_CONCH, preprocess=preprocess_CONCH):\n",
    "        # resize to 256 by 256\n",
    "        base_width = 256\n",
    "        patch_resized = patch.resize((base_width, base_width), Image.Resampling.LANCZOS)\n",
    "        patch_processed = preprocess(patch_resized).unsqueeze(0)\n",
    "        with torch.inference_mode():\n",
    "            feature_emb = model.encode_image(patch_processed.to(device), \n",
    "                                             proj_contrast=False, \n",
    "                                             normalize=False)        # [1, 512]\n",
    "        return torch.clone(feature_emb)\n",
    "    \n",
    "    def get_img_embd_uni(patch, model=model_UNI, transform=transform_UNI):\n",
    "        # resize to 224 by 224\n",
    "        base_width = 224\n",
    "        patch_resized = patch.resize((base_width, base_width), \n",
    "                                     Image.Resampling.LANCZOS)       # [224, 224]\n",
    "        img_transformed = transform(patch_resized).unsqueeze(dim=0)  # [1, 3, 224, 224]\n",
    "        with torch.inference_mode():\n",
    "            feature_emb = model(img_transformed.to(device))          # [1, 1024]\n",
    "        return torch.clone(feature_emb)\n",
    "\n",
    "    def patch_augmentation_embd(patch, conch_or_uni,\n",
    "                                num_transpose = 7):\n",
    "        if conch_or_uni == \"conch\":\n",
    "            embd_dim = 512\n",
    "        elif conch_or_uni == \"uni\":\n",
    "            embd_dim = 1024\n",
    "        patch_aug_embd = torch.zeros(num_transpose, embd_dim)\n",
    "        for trans in range(num_transpose):    # apply augmentations to the image patch\n",
    "            patch_transposed = patch.transpose(trans)\n",
    "            if conch_or_uni == \"conch\":\n",
    "                patch_embd = get_img_embd_conch(patch_transposed)\n",
    "            elif conch_or_uni == \"uni\":\n",
    "                patch_embd = get_img_embd_uni(patch_transposed)\n",
    "            patch_aug_embd[trans, :] = torch.clone(patch_embd)\n",
    "        return patch_aug_embd.unsqueeze(0)\n",
    "\n",
    "\n",
    "    # process spot\n",
    "    spot_diameter = adata.uns[\"spatial\"][\"ST\"][\"scalefactors\"][\"spot_diameter_fullres\"]\n",
    "    print(\"Spot diameter: \", spot_diameter)  # Spot diameter for Visium\n",
    "    if spot_diameter < 224: \n",
    "        radius = 112                         # minimum patch size: 224 by 224\n",
    "    else:\n",
    "        radius = int(spot_diameter // 2)\n",
    "    x = adata.obsm[\"spatial\"][:, 0]          # x coordinate in H&E image\n",
    "    y = adata.obsm[\"spatial\"][:, 1]          # y coordinate in H&E image\n",
    "\n",
    "    all_patch_ebd_conch = None\n",
    "    all_patch_ebd_uni = None\n",
    "    all_patch_ebd_conch_aug = None\n",
    "    all_patch_ebd_uni_aug = None\n",
    "    first = True\n",
    "\n",
    "    for spot_idx in tqdm(range(len(x))):\n",
    "        patch = img.crop((x[spot_idx]-radius, y[spot_idx]-radius, \n",
    "                          x[spot_idx]+radius, y[spot_idx]+radius))\n",
    "        patch_ebd_conch = get_img_embd_conch(patch)\n",
    "        patch_ebd_uni   = get_img_embd_uni(patch)\n",
    "        patch_ebd_conch_aug = patch_augmentation_embd(patch, \"conch\")\n",
    "        patch_ebd_uni_aug   = patch_augmentation_embd(patch, \"uni\")\n",
    "\n",
    "        if first:\n",
    "            all_patch_ebd_conch = patch_ebd_conch\n",
    "            all_patch_ebd_uni   = patch_ebd_uni\n",
    "            all_patch_ebd_conch_aug = patch_ebd_conch_aug\n",
    "            all_patch_ebd_uni_aug   = patch_ebd_uni_aug\n",
    "            first = False\n",
    "        else:\n",
    "            all_patch_ebd_conch = torch.cat((all_patch_ebd_conch, patch_ebd_conch), dim=0)\n",
    "            all_patch_ebd_uni   = torch.cat((all_patch_ebd_uni, patch_ebd_uni), dim=0)\n",
    "            all_patch_ebd_conch_aug = torch.cat((all_patch_ebd_conch_aug, patch_ebd_conch_aug), dim=0)\n",
    "            all_patch_ebd_uni_aug   = torch.cat((all_patch_ebd_uni_aug, patch_ebd_uni_aug), dim=0)\n",
    "    print(\"Final data size: \", \n",
    "          all_patch_ebd_conch.shape, \n",
    "          all_patch_ebd_uni.shape,\n",
    "          all_patch_ebd_conch_aug.shape,\n",
    "          all_patch_ebd_uni_aug.shape)    \n",
    "\n",
    "    if save_path != None:\n",
    "        torch.save(all_patch_ebd_conch.detach().cpu(), save_path + \"1spot_conch_ebd/\" + samplename + \"_conch.pt\")\n",
    "        torch.save(all_patch_ebd_uni.detach().cpu(),   save_path + \"1spot_uni_ebd/\"   + samplename + \"_uni.pt\")\n",
    "        torch.save(all_patch_ebd_conch_aug.detach().cpu(), save_path + \"1spot_conch_ebd_aug/\" + samplename + \"_conch_aug.pt\")\n",
    "        torch.save(all_patch_ebd_uni_aug.detach().cpu(),   save_path + \"1spot_uni_ebd_aug/\"   + samplename + \"_uni_aug.pt\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Load dataset downloaded from hest1k: [example: PRAD]"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "metadata": {},
   "outputs": [],
   "source": [
    "# define paths\n",
    "data_path = \"./hest1k_datasets/PRAD/\"   # local path to dataset\n",
    "tif_path = data_path + 'wsis/'          # H&E image path\n",
    "st_path = data_path + \"st/\"             # ST data path"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# load ST adata\n",
    "adata_lst = []\n",
    "fn_lst = [\"MEND\"+str(i) for i in range(139, 163)] # filename for PRAD dataset\n",
    "fn_lst.remove(\"MEND155\")                          # no MEND155 in the dataset\n",
    "\n",
    "first = True\n",
    "for fn in fn_lst:\n",
    "    adata = anndata.read_h5ad(st_path + fn + \".h5ad\")\n",
    "    adata_lst.append(adata)\n",
    "    if first:\n",
    "        common_genes = adata.var_names \n",
    "        first = False\n",
    "        print(fn, adata.shape)\n",
    "        continue\n",
    "    common_genes = set(common_genes).intersection(set(adata.var_names))\n",
    "    print(fn, adata.shape, end=\"\\t\")\n",
    "\n",
    "# keep common genes\n",
    "print(\"Length of common genes: \", len(common_genes))\n",
    "common_genes = sorted(list(common_genes))\n",
    "for fni in range(len(fn_lst)):\n",
    "    adata = adata_lst[fni].copy()\n",
    "    adata_lst[fni] = adata[:, common_genes].copy()\n",
    "    print(fn_lst[fni], \" \", adata_lst[fni].shape)\n",
    "print(\"Only keep common genes across the slides.\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# generate image patch embeddings (UNI and CONCH, original and augmented)\n",
    "\n",
    "os.makedirs(data_path + \"processed_data/\", exist_ok=True)\n",
    "os.makedirs(data_path + \"processed_data/1spot_conch_ebd/\")\n",
    "os.makedirs(data_path + \"processed_data/1spot_uni_ebd/\")\n",
    "os.makedirs(data_path + \"processed_data/1spot_conch_ebd_aug/\")\n",
    "os.makedirs(data_path + \"processed_data/1spot_uni_ebd_aug/\")\n",
    "# if error, folder exists.\n",
    "\n",
    "for i in range(len(fn_lst)):\n",
    "    fn = fn_lst[i]\n",
    "    adata = adata_lst[i]\n",
    "    print(fn)\n",
    "    image = Image.open(tif_path + fn + \".tif\")\n",
    "    get_img_patch_embd(image, adata, fn, device=\"cuda\", \n",
    "                       save_path=data_path + \"processed_data/\")\n",
    "    print(\"#\" * 20)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Select genes  \n",
    "- Following code as an example\n",
    "- Could be any given gene list saved in: data_path + \"processed_data/selected_gene_list.txt\""
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "union_hvg = set()\n",
    "\n",
    "for fn_idx in range(len(fn_lst)):\n",
    "    adata = adata_lst[fn_idx].copy()\n",
    "    fn = fn_lst[fn_idx]\n",
    "    \n",
    "    sc.pp.filter_cells(adata, min_genes=1)\n",
    "    sc.pp.filter_genes(adata, min_cells=1)\n",
    "    sc.pp.normalize_total(adata, inplace=True)\n",
    "    sc.pp.log1p(adata)\n",
    "    sc.pp.highly_variable_genes(adata, n_top_genes=2000)\n",
    "\n",
    "    union_hvg = union_hvg.union(set(adata.var_names[adata.var[\"highly_variable\"]]))\n",
    "    print(fn, len(union_hvg))\n",
    "\n",
    "union_hvg = sorted([gene for gene in union_hvg if not gene.startswith((\"MT\", \"mt\", \"RPS\", \"RPL\"))]) # [optional] remove mitochondrial genes and ribosomal genes\n",
    "print(len(union_hvg))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# select union_hvg and concat all slides\n",
    "all_count_df = pd.DataFrame(adata_lst[0][:, union_hvg].X.toarray(), \n",
    "                            columns=union_hvg, \n",
    "                            index=[fn_lst[0] + \"_\" + str(i) for i in range(adata_lst[0].shape[0])]).T\n",
    "\n",
    "for fn_idx in range(1, len(fn_lst)):\n",
    "    adata = adata_lst[fn_idx]\n",
    "    df = pd.DataFrame(adata[:, union_hvg].X.toarray(), \n",
    "                      columns=union_hvg, \n",
    "                      index=[fn_lst[fn_idx] + \"_\" + str(i) for i in range(adata.shape[0])]).T\n",
    "    all_count_df = pd.concat([all_count_df, df], axis=1)\n",
    "    print(fn_lst[fn_idx], adata.shape, all_count_df.shape)\n",
    "\n",
    "all_count_df.fillna(0, inplace=True)\n",
    "all_count_df = all_count_df.T"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# order selected genes by mean and std\n",
    "all_gene_order_by_mean = all_count_df.mean(axis=0).sort_values(ascending=False).index\n",
    "all_gene_order_by_std = all_count_df.std(axis=0).sort_values(ascending=False).index"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# select top intersection of high mean and high variance genes\n",
    "\n",
    "num_genes = 241 # to make final gene list of length 200\n",
    "\n",
    "selected_genes = sorted(list(set(all_gene_order_by_mean[:num_genes]).intersection(set(all_gene_order_by_std[:num_genes]))))\n",
    "print(len(selected_genes))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "with open(data_path + \"processed_data/selected_gene_list.txt\", \"w\") as f:\n",
    "    for gene in selected_genes:\n",
    "        f.write(gene + \"\\n\")\n",
    "\n",
    "with open(data_path + \"processed_data/all_slide_lst.txt\", \"w\") as f:\n",
    "    for fn in fn_lst:\n",
    "        f.write(fn + \"\\n\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Additional Pathology Foundation Models:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "H-optimus-0: https://huggingface.co/bioptimus/H-optimus-0"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from huggingface_hub import login\n",
    "login(token=\"\") # TODO: need to replace \"\" by HuggingFace Login Token\n",
    "\n",
    "from torchvision import transforms\n",
    "\n",
    "def get_img_patch_embd(img, \n",
    "                       adata,\n",
    "                       samplename,\n",
    "                       device,\n",
    "                       save_path=None):\n",
    "\n",
    "    # load model\n",
    "    model = timm.create_model(\"hf-hub:bioptimus/H-optimus-0\", pretrained=True, \n",
    "                              init_values=1e-5, dynamic_img_size=False)\n",
    "    model = model.to(device)\n",
    "    model = model.eval()\n",
    "    transform = transforms.Compose([\n",
    "    transforms.ToTensor(),\n",
    "    transforms.Normalize(\n",
    "        mean=(0.707223, 0.578729, 0.703617), \n",
    "        std=(0.211883, 0.230117, 0.177517)\n",
    "    ),\n",
    "])\n",
    "\n",
    "    \n",
    "    def get_img_embd(patch, model=model, transform=transform):\n",
    "        # resize to 224 by 224\n",
    "        base_width = 224\n",
    "        patch_resized = patch.resize((base_width, base_width), \n",
    "                                     Image.Resampling.LANCZOS) # [224, 224]\n",
    "        img_transformed = transform(transforms.ToPILImage()(torch.tensor(np.array(patch_resized)).permute(2, 0, 1))).unsqueeze(0)  # [1, 3, 224, 224]\n",
    "        with torch.inference_mode(), torch.autocast(device_type=device, dtype=torch.float16):\n",
    "            features = model(img_transformed.to(device))\n",
    "        return torch.clone(features).detach().cpu()\n",
    "\n",
    "    def patch_augmentation_embd(patch, num_transpose = 7):\n",
    "        embd_dim = 1536\n",
    "        patch_aug_embd = torch.zeros(num_transpose, embd_dim)\n",
    "        for trans in range(num_transpose):\n",
    "            patch_transposed = patch.transpose(trans)\n",
    "            patch_embd = get_img_embd(patch_transposed)\n",
    "            patch_aug_embd[trans, :] = torch.clone(patch_embd)\n",
    "        return patch_aug_embd.unsqueeze(0)\n",
    "    \n",
    "    # process spot\n",
    "    spot_diameter = adata.uns[\"spatial\"][\"ST\"][\"scalefactors\"][\"spot_diameter_fullres\"]\n",
    "    print(\"Spot diameter: \", spot_diameter)  # Spot diameter for Visium\n",
    "    if spot_diameter < 224: \n",
    "        radius = 112                         # minimum patch size: 224 by 224\n",
    "    else:\n",
    "        radius = int(spot_diameter // 2)\n",
    "    x = adata.obsm[\"spatial\"][:, 0]          # x coordinate in H&E image\n",
    "    y = adata.obsm[\"spatial\"][:, 1]          # y coordinate in H&E image\n",
    "\n",
    "\n",
    "    img_embd = None\n",
    "    img_embd_aug = None\n",
    "    first_patch = True\n",
    "\n",
    "    for spot_idx in tqdm(range(len(x))):\n",
    "        patch = img.crop((x[spot_idx]-radius, y[spot_idx]-radius, \n",
    "                          x[spot_idx]+radius, y[spot_idx]+radius))\n",
    "        patch_embd     = get_img_embd(patch)\n",
    "        patch_embd_aug = patch_augmentation_embd(patch)\n",
    "\n",
    "        if first_patch:\n",
    "            img_embd = patch_embd\n",
    "            img_embd_aug = patch_embd_aug\n",
    "            first_patch = False\n",
    "        else:\n",
    "            img_embd = torch.cat((img_embd, patch_embd), dim=0)\n",
    "            img_embd_aug = torch.cat((img_embd_aug, patch_embd_aug), dim=0)\n",
    "\n",
    "    print(\"Final size:\", img_embd.shape, img_embd_aug.shape)\n",
    "\n",
    "    if save_path is not None:\n",
    "        torch.save(img_embd,     save_path + f\"1spot_hopt0_ebd/{samplename}_hopt0.pt\")\n",
    "        torch.save(img_embd_aug, save_path + f\"1spot_hopt0_ebd_aug/{samplename}_hopt0.pt\")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Virchow2: https://huggingface.co/paige-ai/Virchow2"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 14,
   "metadata": {},
   "outputs": [],
   "source": [
    "from huggingface_hub import login\n",
    "login(token=\"\") # TODO: need to replace \"\" by HuggingFace Login Token\n",
    "import timm\n",
    "import torch\n",
    "from timm.data import resolve_data_config\n",
    "from timm.data.transforms_factory import create_transform\n",
    "from timm.layers import SwiGLUPacked\n",
    "\n",
    "def get_img_patch_embd(img, \n",
    "                       adata,\n",
    "                       samplename,\n",
    "                       device,\n",
    "                       save_path=None):\n",
    "\n",
    "    # load model\n",
    "    # need to specify MLP layer and activation function for proper init\n",
    "    model = timm.create_model(\"hf-hub:paige-ai/Virchow2\", pretrained=True, \n",
    "                              mlp_layer=SwiGLUPacked, act_layer=torch.nn.SiLU)\n",
    "    model = model.to(device)\n",
    "    model = model.eval()\n",
    "    transforms = create_transform(**resolve_data_config(model.pretrained_cfg, model=model))\n",
    "\n",
    "    \n",
    "    def get_img_embd(patch, model=model, transform=transforms):\n",
    "        # resize to 224 by 224\n",
    "        base_width = 224\n",
    "        patch_resized = patch.resize((base_width, base_width), \n",
    "                                     Image.Resampling.LANCZOS) # [224, 224]\n",
    "        img_transformed = transform(patch_resized).unsqueeze(0)  # [1, 3, 224, 224]\n",
    "        with torch.inference_mode(), torch.autocast(device_type=\"cuda\", dtype=torch.float16):\n",
    "            output = model(img_transformed.to(device))  # [1, 2560]\n",
    "            class_token = output[:, 0]\n",
    "            patch_tokens = output[:, 5:]\n",
    "            embedding = torch.cat([class_token, patch_tokens.mean(1)], dim=-1)\n",
    "        return torch.clone(embedding).detach().cpu()\n",
    "\n",
    "    def patch_augmentation_embd(patch, num_transpose = 7):\n",
    "        embd_dim = 2560\n",
    "        patch_aug_embd = torch.zeros(num_transpose, embd_dim)\n",
    "        for trans in range(num_transpose):\n",
    "            patch_transposed = patch.transpose(trans)\n",
    "            patch_embd = get_img_embd(patch_transposed)\n",
    "            patch_aug_embd[trans, :] = torch.clone(patch_embd)\n",
    "        return patch_aug_embd.unsqueeze(0)\n",
    "    \n",
    "    \n",
    "    # process spot\n",
    "    spot_diameter = adata.uns[\"spatial\"][\"ST\"][\"scalefactors\"][\"spot_diameter_fullres\"]\n",
    "    print(\"Spot diameter: \", spot_diameter)  # Spot diameter for Visium\n",
    "    if spot_diameter < 224: \n",
    "        radius = 112                         # minimum patch size: 224 by 224\n",
    "    else:\n",
    "        radius = int(spot_diameter // 2)\n",
    "    x = adata.obsm[\"spatial\"][:, 0]          # x coordinate in H&E image\n",
    "    y = adata.obsm[\"spatial\"][:, 1]          # y coordinate in H&E image\n",
    "\n",
    "\n",
    "    # initialize variables\n",
    "    first_patch = True\n",
    "    img_embd = None\n",
    "    img_embd_aug = None\n",
    "\n",
    "    for spot_idx in tqdm(range(len(x))):\n",
    "        patch = img.crop((x[spot_idx]-radius, y[spot_idx]-radius, \n",
    "                          x[spot_idx]+radius, y[spot_idx]+radius))        \n",
    "        patch_embd     = get_img_embd(patch)\n",
    "        patch_embd_aug = patch_augmentation_embd(patch)\n",
    "\n",
    "        if first_patch:\n",
    "            img_embd = patch_embd\n",
    "            img_embd_aug = patch_embd_aug\n",
    "            first_patch = False\n",
    "        else:\n",
    "            img_embd = torch.cat((img_embd, patch_embd), dim=0)\n",
    "            img_embd_aug = torch.cat((img_embd_aug, patch_embd_aug), dim=0)\n",
    "\n",
    "    print(\"Final size:\", img_embd.shape, img_embd_aug.shape)\n",
    "\n",
    "    if save_path is not None:\n",
    "        torch.save(img_embd,     save_path + f\"1spot_virchow2_ebd/{samplename}_virchow2.pt\")\n",
    "        torch.save(img_embd_aug, save_path + f\"1spot_virchow2_ebd_aug/{samplename}_virchow2.pt\")\n"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "virchow",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.11.10"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
